Skip to content

fix: frontend state management bugs and performance#715

Merged
2witstudios merged 9 commits intomasterfrom
ppg/frontend-state-fixes
Feb 27, 2026
Merged

fix: frontend state management bugs and performance#715
2witstudios merged 9 commits intomasterfrom
ppg/frontend-state-fixes

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Feb 24, 2026

Summary

  • Fix data loss bug: Migrate CanvasPageView from useDocumentStore (shared saveTimeoutId) to useDocumentManagerStore (per-document Map<string, DocumentState>)
  • Fix SWR initial fetch blocked: Add hasLoadedRef guard to useDevices and usePageAgents so isPaused never blocks the first fetch
  • Fix SheetView socket churn: Move documentState.content/isDirty reads into refs so the socket useEffect doesn't re-subscribe on every keystroke
  • Add React.memo to PageTreeItem: Most heavily-rendered component (one per tree node) now skips re-render when props are unchanged
  • Fix stale tab titles: EditableTitle now syncs renamed page title to both useOpenTabsStore and useTabsStore
  • Fix permission fetch race: Add AbortController to permission checks in DocumentView and SheetView so stale responses don't overwrite isReadOnly
  • Fix CanvasPageView save lifecycle: Reset isDirty after successful debounced save; force-save dirty content on unmount
  • Fix save error propagation (review follow-up): saveContent now rethrows so failed saves leave isDirty=true
  • Fix isDirty race condition (review follow-up): Version counter ensures only the latest save can clear dirty state
  • Fix stale content on revisit (review follow-up): Initialize effect refreshes cached doc from props; unmount clears document cache

Test plan

  • Open two documents simultaneously, edit both, verify both save correctly (no lost data)
  • Start editing a document, then navigate to Devices page — verify devices list loads
  • Open a sheet, type rapidly, verify socket listener doesn't thrash (check console for re-subscribe logs)
  • Expand a large page tree, verify smooth scrolling without jank
  • Rename a page via the title header, verify tab title updates in both tab bars
  • Rapidly switch between pages, verify permission state is correct for each page
  • Edit canvas, navigate away within 1s — verify content is saved (force-save on unmount)
  • Edit canvas, simulate network failure — verify isDirty stays true and content isn't lost
  • Edit canvas rapidly while save is in-flight — verify newer edits aren't cleared by stale save response
  • Navigate away from canvas and back — verify fresh content is shown, not stale cache

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tab titles now sync automatically when a page title is updated
    • New action to update tab metadata by page ID
  • Bug Fixes

    • Auto-save now triggers on window blur and on unmount to preserve edits
    • Improved permission request cancellation and user notification on forbidden/edit-disabled access
  • Improvements

    • Per-page document manager with debounced saves and safer real-time updates
    • Sidebar items memoized for rendering performance
    • Background refresh pausing refined; legacy document store marked deprecated
  • Tests

    • Added comprehensive save-lifecycle and hook tests covering load/pausing and save behaviors

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Memoizes a sidebar item, migrates canvas/sheet views to a per-page document manager with debounced saves and unmount flush, syncs edited page titles to open-tabs/tabs store, adds AbortController-backed permission checks with toast, and changes SWR pausing to require an initial successful load before pausing during editing.

Changes

Cohort / File(s) Summary
Sidebar component memoization
apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
Wraps PageTreeItem in React.memo, adds React named import, converts export from function declaration to memoized const.
Per-page document manager & autosave
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx, apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx, apps/web/src/stores/useDocumentStore.ts
Replaces prior document-store usage with useDocumentManagerStore (documents map, create/update/clear), adds debounced save (saveTimeoutRef/saveVersionRef), setContent/updateContentFromServer, editing lifecycle hooks, force-save on unmount, and marks old useDocumentStore as deprecated in a comment.
Tab metadata & title sync
apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx, apps/web/src/stores/useTabsStore.ts
Adds useOpenTabsStore and useTabsStore updates in title flow; implements updateTabMetaByPageId(pageId, meta) to update tab metadata by page id and synchronizes open-tab titles after a successful title PATCH.
Permission checks with abort handling
apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx, apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
Introduces AbortController for permission fetches, passes abort signal, ignores AbortError on cleanup, and surfaces a toast when edit permissions are forbidden/disabled.
SWR pause gating (hasLoadedRef)
apps/web/src/hooks/page-agents/usePageAgents.ts, apps/web/src/hooks/useDevices.ts, apps/web/src/hooks/__tests__/useDevices.test.ts
Adds hasLoadedRef + onSuccess so SWR isPaused only pauses during editing after the initial successful load; updates tests to reflect new semantics.
Tests: canvas save lifecycle & page-agents
apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/*, apps/web/src/hooks/page-agents/__tests__/usePageAgents.test.ts
Adds comprehensive save-lifecycle tests for CanvasPageView (debounce, version guard, unmount force-save, server conflict handling) and tests for usePageAgents SWR behavior with hasLoadedRef.
Misc tests & deprecation note
apps/web/src/hooks/__tests__/useDevices.test.ts, apps/web/src/stores/useDocumentStore.ts
Updates SWR-related tests to include onSuccess behavior and adds a deprecation comment to useDocumentStore advising useDocumentManagerStore for content management.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Canvas as CanvasPageView
    participant DocManager as DocumentManagerStore
    participant SaveSched as SaveScheduler
    participant API as API

    User->>Canvas: edit content
    Canvas->>DocManager: setContent(newContent) [mark isDirty, inc version]
    Canvas->>SaveSched: schedule debounced save
    SaveSched-->>Canvas: debounce fires
    Canvas->>API: saveContent(latestContent, version)
    API-->>Canvas: save success
    Canvas->>DocManager: clear isDirty if version matches, update lastSaved
    User->>Canvas: navigate away / unmount
    Canvas->>Canvas: if isDirty -> force save before unmount
Loading
sequenceDiagram
    actor User
    participant Editable as EditableTitle
    participant API as API
    participant OpenTabs as OpenTabsStore
    participant Tabs as TabsStore

    User->>Editable: submit new title
    Editable->>API: PATCH /pages/:id {title}
    API-->>Editable: 200 OK (updated page)
    Editable->>OpenTabs: update open tab title for pageId
    Editable->>Tabs: updateTabMetaByPageId(pageId, {title: newTitle})
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through code with whiskers bright,

Memo’d a leaf to keep renders light,
Debounced the hops, saved them on cue,
Titles now sing across tabs anew,
Abort guards keep fetches tidy and right.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: frontend state management bugs and performance' accurately summarizes the main changes: fixing state management issues (CanvasPageView store migration, SWR hydration guards, tab sync) and improving performance (React.memo, ref-based reads, debounced saves).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ppg/frontend-state-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c02eca689

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@2witstudios
Copy link
Owner Author

Re: Codex review on CanvasPageView cleanup (P1: flush pending edits)

Addressed in commit 7c02f1d. The cleanup effect was split into two:

  1. Initialization effect (depends on page.id, page.content) — creates the document in the manager store
  2. Force-save on unmount (empty deps, runs only on TRUE unmount) — clears the debounce timer AND force-saves dirty content via saveContentRef

Additionally, the debounced save timeout now resets isDirty: false on success, so the store state stays accurate throughout the save lifecycle.

@2witstudios
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 52-73: The debounce currently catches all errors so the
success-path updateDocument call clears isDirty even when save fails; remove the
empty catch here (or rethrow the error) so failures from saveContent(page.id,
newContent) propagate and prevent updating isDirty. Update the setContent
callback (inside the setTimeout) to either await saveContent without a try/catch
or, if you must catch to log, rethrow the caught error; ensure saveContent still
throws on failure so useDocumentManagerStore.getState().updateDocument(page.id,
{ isDirty: false, lastSaved: ... }) only runs when saveContent resolves
successfully.
- Around line 88-92: The mounted effect calls
useDocumentManagerStore.getState().createDocument(page.id, ...) but
createDocument is a no-op if the doc exists, causing stale content when
revisiting; fix by first reading the cached document via
useDocumentManagerStore.getState().getDocument(page.id) (or equivalent getter)
and if it exists compare its text to the fresh page.content and call an update
method (e.g., updateDocument or createDocument with overwrite) when they differ;
additionally add a cleanup in the same useEffect that removes or clears the
document on unmount (e.g.,
useDocumentManagerStore.getState().deleteDocument(page.id) or resetDocument) so
documents don't persist across remounts when out-of-band server changes occur.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6247d21 and 7c02f1d.

📒 Files selected for processing (8)
  • apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
  • apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx
  • apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
  • apps/web/src/hooks/page-agents/usePageAgents.ts
  • apps/web/src/hooks/useDevices.ts
  • apps/web/src/stores/useDocumentStore.ts

@2witstudios
Copy link
Owner Author

Addressing Review Feedback (d556e65)

Thread 1: Codex Bot P1 — Flush pending canvas edits before clearing debounce timer

The codex bot reviewed commit 8c02eca which didn't have the force-save on unmount. Commit 7c02f1d added it, and d556e65 further strengthens it:

  • saveContent now rethrows so the debounce catch block leaves isDirty=true on network failure
  • saveVersionRef counter prevents a stale in-flight save from clearing isDirty when newer edits exist
  • Unmount cleanup flushes pending debounce, force-saves if dirty, then clears the document from the cache

Thread 2: CodeRabbit Critical — Debounced save can clear isDirty even when save fails

Fixed. saveContent now re-throws after logging/toasting. The catch block in the debounce no longer clears isDirty, so failed saves leave the document dirty for retry or unmount force-save.

Thread 3: CodeRabbit Major — Stale content shown when revisiting canvas page

Fixed. The initialize effect now checks for an existing cached document and refreshes it from the prop if the content differs and the doc isn't dirty. On unmount, clearDocument is called after force-save to prevent stale cache across remounts.

CI Fix: useDevices.test.ts (2 failing tests)

Updated tests to match the hasLoadedRef behavior: isPaused only blocks revalidation after the initial fetch completes (via onSuccess). Before first load, isPaused returns false even when editing is active — this is the intended behavior to prevent blocking initial data hydration.

User Review: P1 — Keep canvas dirty state until latest save confirmed

Fixed via saveVersionRef. Each call to setContent increments the version counter. After a save completes, isDirty is only cleared if the version hasn't changed — meaning no newer edits arrived while the save was in flight. Combined with the error rethrow, this ensures isDirty stays true whenever edits are unsaved, whether due to newer typing or network failure.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/web/src/hooks/__tests__/useDevices.test.ts (1)

75-75: Guard onSuccess with toBeDefined before calling it via non-null assertion.

Both lines call swrConfig.onSuccess!() without first asserting the field exists. If onSuccess is ever removed from useDevices.ts, the tests will throw an unguarded runtime error instead of a clean, descriptive assertion failure.

🛡️ Proposed fix (shown for line 75; apply the same pattern at line 101)
+      expect(swrConfig.onSuccess).toBeDefined();
       swrConfig.onSuccess!();

Also applies to: 101-101

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/__tests__/useDevices.test.ts` at line 75, Add a guard
asserting that swrConfig.onSuccess is defined before invoking it in the test:
replace the direct non-null assertion call with an assertion like
expect(swrConfig.onSuccess).toBeDefined(); followed by the call to
swrConfig.onSuccess!(); Apply this change for both places where
swrConfig.onSuccess is invoked in the useDevices.test.ts tests so the test fails
with a clear assertion if the handler is removed.
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (1)

246-251: React.memo comparator is appropriate but note the coupling with unmount cleanup.

The comparator correctly limits re-renders to changes in page.id and page.content. However, because it allows re-renders on page.id change (not just remounts), the empty-deps unmount effect (lines 112-124) must use refs to access the current page.id — see related comment above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 246 - 251, The memo comparator on CanvasPageView only prevents
renders when page.id and page.content are unchanged, but because
CanvasPageView's empty-deps unmount effect (the cleanup run on component
unmount) currently closes over the original page.id, update that effect to read
the current page id from a ref instead of the stale prop: add a pageIdRef (e.g.,
const pageIdRef = useRef(page.id)), update pageIdRef.current on each render or
in a useEffect that depends on page.id, and then reference pageIdRef.current
inside the cleanup callback so cleanup uses the latest page id when the
component unmounts; keep the React.memo comparator as-is (export default
React.memo(CanvasPageView, ...)).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 112-124: The cleanup effect captures a stale page.id; update the
component to track the current page id in a ref (e.g., pageIdRef) and keep it in
sync whenever props.page.id changes, then use pageIdRef.current inside the
useEffect cleanup instead of the closed-over page.id; specifically, create
pageIdRef (like saveContentRef), set pageIdRef.current = page.id in a short
effect that runs on page.id changes, and in the existing useEffect cleanup
replace references to page.id with pageIdRef.current when calling
saveContentRef.current(...) and
useDocumentManagerStore.getState().clearDocument(...).
- Around line 23-32: CanvasPageView never registers editing with the global
editing store; add a useEffect inside the CanvasPageView component that calls
useEditingStore.getState().startEditing() when the component enters edit mode
(e.g., when activeTab === 'edit' or when the editor is focused) and returns a
cleanup that calls useEditingStore.getState().stopEditing() (or the appropriate
end method) to unregister on unmount or when leaving edit mode; place this
effect in CanvasPageView near the other refs/hooks (alongside
containerRef/saveTimeoutRef) so SWR revalidation is suppressed while editing.

---

Nitpick comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 246-251: The memo comparator on CanvasPageView only prevents
renders when page.id and page.content are unchanged, but because
CanvasPageView's empty-deps unmount effect (the cleanup run on component
unmount) currently closes over the original page.id, update that effect to read
the current page id from a ref instead of the stale prop: add a pageIdRef (e.g.,
const pageIdRef = useRef(page.id)), update pageIdRef.current on each render or
in a useEffect that depends on page.id, and then reference pageIdRef.current
inside the cleanup callback so cleanup uses the latest page id when the
component unmounts; keep the React.memo comparator as-is (export default
React.memo(CanvasPageView, ...)).

In `@apps/web/src/hooks/__tests__/useDevices.test.ts`:
- Line 75: Add a guard asserting that swrConfig.onSuccess is defined before
invoking it in the test: replace the direct non-null assertion call with an
assertion like expect(swrConfig.onSuccess).toBeDefined(); followed by the call
to swrConfig.onSuccess!(); Apply this change for both places where
swrConfig.onSuccess is invoked in the useDevices.test.ts tests so the test fails
with a clear assertion if the handler is removed.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c02f1d and d556e65.

📒 Files selected for processing (2)
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/hooks/__tests__/useDevices.test.ts

@2witstudios
Copy link
Owner Author

Addressing Round 2 Review Feedback (e1e70a3)

Thread 4: CodeRabbit — Missing useEditingStore registration

Fixed. Added useEditingStore.getState().startEditing(page.id, 'document') when isDirty is true, per the CLAUDE.md UI Refresh Protection guidelines. This prevents SWR revalidation from disrupting canvas edits. The editing session is cleaned up both on isDirty transitioning to false and on unmount.

Thread 5: CodeRabbit — Stale page.id in unmount cleanup

Fixed. Added pageIdRef (same pattern as saveContentRef) that stays in sync via a short effect. The unmount cleanup now reads pageIdRef.current instead of the closed-over page.id.

Note: The parent (CenterPanel.tsx:174) renders <CanvasPageView key={\canvas-${page.id}`} page={page} />`, so the component always remounts when page changes — but the ref pattern is a defensive improvement regardless.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (1)

140-173: ⚠️ Potential issue | 🟡 Minor

Missing AbortController — stale fetch can overwrite freshly-initialized content on rapid remount.

fetchWithAuth on line 155 is not aborted when the component unmounts or the socket listener is torn down. The unmount cleanup calls clearDocument, so a late response arriving after unmount is a safe no-op. However, if the parent remounts the component with the same page.id before the fetch resolves (the init effect will have re-created the document), the stale response will call updateContentFromServer on the newly-created document, potentially replacing fresher content with an older snapshot.

The PR already uses AbortController in DocumentView and SheetView for permission checks; apply the same pattern here.

🛠️ Proposed fix
  useEffect(() => {
    if (!socket) return;
+   let abortController: AbortController | null = null;

    const handleContentUpdate = async (eventData: PageEventPayload) => {
      if (eventData.socketId && eventData.socketId === socket.id) return;
      if (eventData.pageId === page.id) {
        console.log(`[Canvas] Received content update for page ${page.id}`);
        try {
+         abortController = new AbortController();
-         const response = await fetchWithAuth(`/api/pages/${page.id}`);
+         const response = await fetchWithAuth(`/api/pages/${page.id}`, { signal: abortController.signal });
          if (response.ok) {
            const updatedPage = await response.json();
            const newContent = typeof updatedPage.content === 'string' ? updatedPage.content : '';
            updateContentFromServer(newContent);
          }
        } catch (error) {
+         if (error instanceof DOMException && error.name === 'AbortError') return;
          console.error('Failed to fetch updated canvas content:', error);
        }
      }
    };

    socket.on('page:content-updated', handleContentUpdate);
    return () => {
      socket.off('page:content-updated', handleContentUpdate);
+     abortController?.abort();
    };
  }, [socket, page.id, updateContentFromServer]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 140 - 173, The socket handler handleContentUpdate must use an
AbortController so stale fetches don't overwrite newly-initialized documents:
inside the useEffect create a controller-per-invocation (e.g., create a new
AbortController at the top of handleContentUpdate or push it into an array
controllers declared in the effect scope), pass controller.signal into
fetchWithAuth, and on cleanup for the effect abort all active controllers and
remove the socket listener via socket.off('page:content-updated',
handleContentUpdate); also make the catch ignore AbortError (or skip calling
updateContentFromServer if the fetch was aborted) to avoid applying stale
content; reference handleContentUpdate, fetchWithAuth, updateContentFromServer,
and the socket.on/off setup when making the change.
🧹 Nitpick comments (2)
apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx (2)

82-93: updateContentFromServer should also bump saveVersionRef to invalidate any in-flight debounced save.

clearTimeout only cancels a timer that hasn't fired yet. If the debounced callback is already mid-execution (i.e., awaiting saveContent(...)) when a server update arrives, clearTimeout is a no-op for it. That in-flight save will still complete, pass the saveVersionRef.current === version guard, and overwrite lastSaved with a stale timestamp — even though the authoritative content now came from the server.

Bumping the version counter prevents this:

♻️ Proposed fix
  const updateContentFromServer = useCallback((newContent: string) => {
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
      saveTimeoutRef.current = null;
    }
+   // Invalidate any in-flight debounced save so its version guard fails
+   saveVersionRef.current++;
    useDocumentManagerStore.getState().updateDocument(page.id, {
      content: newContent,
      isDirty: false,
      lastSaved: Date.now(),
      lastUpdateTime: Date.now(),
    });
  }, [page.id]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 82 - 93, The updateContentFromServer handler currently clears the
debounce timer but does not increment the in-flight save version, so an
already-running save (awaiting saveContent) can still pass the
saveVersionRef.current === version check and overwrite server-updated state;
inside updateContentFromServer (function name) after clearing saveTimeoutRef
(saveTimeoutRef.current) and before calling updateDocument, increment or bump
saveVersionRef.current (e.g., ++saveVersionRef.current or assign a new token) so
any in-flight save will fail its version guard (saveVersionRef and the
saveContent version check) and be prevented from writing stale
lastSaved/lastUpdateTime.

259-264: Custom memo comparator silently suppresses re-renders for any new TreePage fields added in the future.

The comparator is correct for the current component surface (page.id and page.content are the only page fields consumed). Consider adding a brief comment so future contributors know why only these two fields are compared, to avoid accidentally introducing silent stale-prop bugs if new page fields (e.g., page.title, page.permissions) are later referenced in the component.

♻️ Suggested comment
 export default React.memo(
   CanvasPageView,
+  // Only page.id and page.content drive this component's rendering and effects.
+  // If you add consumption of other page fields, extend this comparator accordingly.
   (prevProps, nextProps) =>
     prevProps.page.id === nextProps.page.id &&
     prevProps.page.content === nextProps.page.content
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`
around lines 259 - 264, The custom React.memo comparator on CanvasPageView
currently only compares prevProps.page.id and prevProps.page.content which will
silently prevent re-renders if new TreePage fields are later used; update the
memo call by adding a concise comment above the comparator explaining that the
comparator intentionally limits checks to page.id and page.content (and that any
time a new page field like page.title or page.permissions is consumed by
CanvasPageView the comparator must be updated to include it or be removed), and
ensure the comment references CanvasPageView, prevProps/nextProps and the
page.id/page.content checks so future contributors understand why only those
fields are compared.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 140-173: The socket handler handleContentUpdate must use an
AbortController so stale fetches don't overwrite newly-initialized documents:
inside the useEffect create a controller-per-invocation (e.g., create a new
AbortController at the top of handleContentUpdate or push it into an array
controllers declared in the effect scope), pass controller.signal into
fetchWithAuth, and on cleanup for the effect abort all active controllers and
remove the socket listener via socket.off('page:content-updated',
handleContentUpdate); also make the catch ignore AbortError (or skip calling
updateContentFromServer if the fetch was aborted) to avoid applying stale
content; reference handleContentUpdate, fetchWithAuth, updateContentFromServer,
and the socket.on/off setup when making the change.

---

Nitpick comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 82-93: The updateContentFromServer handler currently clears the
debounce timer but does not increment the in-flight save version, so an
already-running save (awaiting saveContent) can still pass the
saveVersionRef.current === version check and overwrite server-updated state;
inside updateContentFromServer (function name) after clearing saveTimeoutRef
(saveTimeoutRef.current) and before calling updateDocument, increment or bump
saveVersionRef.current (e.g., ++saveVersionRef.current or assign a new token) so
any in-flight save will fail its version guard (saveVersionRef and the
saveContent version check) and be prevented from writing stale
lastSaved/lastUpdateTime.
- Around line 259-264: The custom React.memo comparator on CanvasPageView
currently only compares prevProps.page.id and prevProps.page.content which will
silently prevent re-renders if new TreePage fields are later used; update the
memo call by adding a concise comment above the comparator explaining that the
comparator intentionally limits checks to page.id and page.content (and that any
time a new page field like page.title or page.permissions is consumed by
CanvasPageView the comparator must be updated to include it or be removed), and
ensure the comment references CanvasPageView, prevProps/nextProps and the
page.id/page.content checks so future contributors understand why only those
fields are compared.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d556e65 and e1e70a3.

📒 Files selected for processing (1)
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx

2witstudios and others added 4 commits February 25, 2026 18:24
- Migrate CanvasPageView from useDocumentStore to useDocumentManagerStore
  to fix shared saveTimeoutId data loss bug with parallel documents
- Add hasLoadedRef guard to useDevices and usePageAgents SWR isPaused
  to prevent blocking initial data fetch when editing is active
- Move SheetView socket listener deps to refs to stop re-subscribing
  on every keystroke (documentState.content was in useEffect deps)
- Wrap PageTreeItem with React.memo to prevent re-rendering every tree
  node on any parent state change
- Update EditableTitle to sync renamed page title to both tab stores
  (useOpenTabsStore and useTabsStore)
- Add AbortController to permission check fetches in DocumentView and
  SheetView to prevent stale responses overwriting isReadOnly state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address two issues from code review:
- isDirty flag now reset to false after debounced save succeeds
- Dirty content is force-saved on component unmount, matching the
  pattern used in DocumentView and SheetView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rded isDirty, stale content

- saveContent now rethrows errors so debounced save won't clear isDirty on failure
- Add saveVersionRef counter so only the latest save can mark document clean,
  preventing a race where an older in-flight save clears isDirty while newer
  edits are unsaved
- Initialize effect now refreshes cached doc from props when content differs
  and doc isn't dirty, fixing stale content on page revisit
- Unmount cleanup now calls clearDocument after force-save to prevent stale
  cache across remounts
- Update useDevices tests to match hasLoadedRef behavior: isPaused only
  blocks revalidation after initial fetch completes via onSuccess

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…leanup

- Register editing state with useEditingStore when content is dirty,
  preventing SWR revalidation from disrupting canvas edits (per CLAUDE.md
  UI Refresh Protection guidelines)
- Add pageIdRef to avoid stale page.id capture in empty-deps unmount
  cleanup, making the pattern resilient even without key-based remounting
- Clean up useEditingStore session on unmount alongside document cache
- Remove unused eslint-disable directive

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios force-pushed the ppg/frontend-state-fixes branch from e1e70a3 to e6147c6 Compare February 26, 2026 00:41
2witstudios added a commit that referenced this pull request Feb 26, 2026
…t fixes

Reviews all 9 changed files for correctness, performance, security (OWASP top 10),
and adherence to project patterns. Score: 88/100 — approved with follow-up items
for CanvasPageView test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2witstudios and others added 3 commits February 26, 2026 10:00
… tests

- fix(canvas): only clearDocument after successful unmount save; keep dirty
  document in store on failure so content can be recovered on remount
- fix(canvas): guard updateContentFromServer against overwriting local dirty
  edits or in-flight debounced saves (conflict detection)
- fix(page-agents): reset hasLoadedRef when SWR key changes so new key's
  initial fetch isn't incorrectly paused
- fix(editable-title): use path.split('/').pop() instead of fragile endsWith
  for tab-to-page matching
- test: add CanvasPageView save lifecycle tests (10 tests covering debounce,
  version guard, error propagation, unmount force-save, conflict detection)
- test: add usePageAgents hasLoadedRef tests (4 tests including key-change reset)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…SWR key reset

- [HIGH] CanvasPageView unmount: only clearDocument after save succeeds;
  on failure, keep document state so user can retry (prevents data loss)
- [Medium] updateContentFromServer: skip server update when a pending
  local save exists (saveTimeoutRef.current is set) to avoid overwriting
  in-progress edits during concurrent socket updates
- [Medium] usePageAgents: reset hasLoadedRef when SWR key changes so
  the initial fetch for a new key is not incorrectly paused
- [Low] EditableTitle: batch tab-title updates via new
  updateTabMetaByPageId store action (single set() call); uses path
  segment matching instead of fragile endsWith
- [Medium] Add canvas save lifecycle tests (debounce, version guard,
  error propagation, unmount force-save, server update guard)
- [Low] Add usePageAgents hasLoadedRef tests including key-change reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… updates

Without this, the stale timeout ID kept saveTimeoutRef truthy after the
debounce fired, causing updateContentFromServer to reject incoming
server updates even when no debounce was pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
apps/web/src/hooks/page-agents/__tests__/usePageAgents.test.ts (1)

53-55: Optional: extract SWR config lookup helper to reduce repetition.

The same mocked call indexing/cast pattern appears multiple times; a small helper would make the tests easier to maintain.

♻️ Proposed refactor
+type SwrConfigUnderTest = {
+  isPaused?: () => boolean;
+  onSuccess?: () => void;
+};
+
+const getSwrConfig = (): SwrConfigUnderTest => {
+  const swrCall = vi.mocked(useSWR).mock.calls[0];
+  return (swrCall?.[2] ?? {}) as SwrConfigUnderTest;
+};

-      const swrCall = vi.mocked(useSWR).mock.calls[0];
-      const swrConfig = swrCall[2] as { isPaused?: () => boolean; onSuccess?: () => void };
+      const swrConfig = getSwrConfig();

-      const swrCall = vi.mocked(useSWR).mock.calls[0];
-      const swrConfig = swrCall[2] as { isPaused?: () => boolean; onSuccess?: () => void };
+      const swrConfig = getSwrConfig();

-      const swrCall = vi.mocked(useSWR).mock.calls[0];
-      const swrConfig = swrCall[2] as { isPaused?: () => boolean; onSuccess?: () => void };
+      const swrConfig = getSwrConfig();

-      const firstCall = vi.mocked(useSWR).mock.calls[0];
-      const firstConfig = firstCall[2] as { isPaused?: () => boolean; onSuccess?: () => void };
+      const firstConfig = getSwrConfig();

-      const secondCall = vi.mocked(useSWR).mock.calls[0];
-      const secondConfig = secondCall[2] as { isPaused?: () => boolean; onSuccess?: () => void };
+      const secondConfig = getSwrConfig();

Also applies to: 66-68, 82-84, 106-107, 119-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/page-agents/__tests__/usePageAgents.test.ts` around lines
53 - 55, The tests repeatedly extract the SWR mock call and its config using the
same pattern (vi.mocked(useSWR).mock.calls[...] and casting to the config
shape); create a small helper function (e.g., getMockSWRConfig or
extractSWRConfig) used by tests in usePageAgents.test.ts to accept the call
index (or default to 0) and return the typed config object so you can replace
repeated lines that compute swrCall and swrConfig (references: useSWR, swrCall,
swrConfig) with a single call to the helper to reduce duplication and improve
maintainability.
apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts (1)

79-281: These tests don’t execute CanvasPageView save logic.

Most assertions directly mutate store state and call mocks, so regressions in debounce/unmount effects inside the component can still pass unnoticed.

Refactor direction
  • Keep one or two pure store tests.
  • Move lifecycle assertions to component-level tests (render CanvasPageView, trigger MonacoEditor onChange, advance fake timers, unmount, then assert store + patch interactions).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts`
around lines 79 - 281, Tests are mutating useDocumentManagerStore and calling
mockPatch directly so they never exercise CanvasPageView's debounce/unmount save
effects; refactor by keeping 1–2 pure store unit tests but move lifecycle
assertions into component-level tests that render CanvasPageView, simulate user
edits by invoking the MonacoEditor onChange handler (or dispatching events that
trigger CanvasPageView's update flow), advance fake timers to flush the
debounce, unmount the component to trigger the force-save effect, and then
assert store state and mockPatch calls to verify real save/version-guard/error
behaviors.
apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts (1)

1-165: Consolidate overlapping save-lifecycle suites.

This file overlaps heavily with CanvasPageView.save.test.ts; keeping both will likely drift and increase maintenance overhead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts`
around lines 1 - 165, The test file defines a save-lifecycle suite that overlaps
with CanvasPageView.save.test.ts; remove duplication by consolidating behaviors
into a single canonical test file: either delete this file
(apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts)
or merge any unique specs here into CanvasPageView.save.test.ts, ensuring you
keep the authoritative tests for debounce/version-guard/error/unmount scenarios
(tests referencing useDocumentManagerStore, createDocument, updateDocument,
clearDocument) and remove the redundant suite, update any test imports or CI
references, and run the test suite to confirm no regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts`:
- Around line 52-53: The test file uses the React namespace in the ErrorBoundary
prop type (ErrorBoundary: ({ children }: { children: React.ReactNode }) =>
children) but never imports React; add a proper React type import and update the
type usage accordingly — e.g., import type { ReactNode } from 'react' and change
the prop annotation to ({ children }: { children: ReactNode }) or alternatively
import React and keep React.ReactNode; update the ErrorBoundary declaration to
use the imported type.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 134-137: The post-save .then() unconditionally calls
useDocumentManagerStore.getState().clearDocument(id), which can clear a freshly
remounted document if the component remounts before the save resolves; change
the callback to guard against remount races by verifying the saved id still
represents the same live document before clearing—e.g., capture a local mount
token or check a current active document id/version from useDocumentManagerStore
(or an isMounted flag) and only call clearDocument(id) when the token/active id
matches the original id; target saveContentRef.current(...)'s .then() handler
and replace the unconditional clearDocument call with this guarded check.

In
`@apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx`:
- Around line 1550-1553: The fetchWithAuth call in SheetView.tsx constructs a
query string with user.id unencoded which can break URLs if the ID contains
reserved characters; update the call that builds
`/api/pages/${page.id}/permissions/check?userId=${user.id}` to encode the user
id (e.g., use encodeURIComponent(user.id)) before interpolation so the query
parameter is safely encoded while keeping the same abortController.signal and
existing fetchWithAuth usage.

---

Nitpick comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts`:
- Around line 1-165: The test file defines a save-lifecycle suite that overlaps
with CanvasPageView.save.test.ts; remove duplication by consolidating behaviors
into a single canonical test file: either delete this file
(apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts)
or merge any unique specs here into CanvasPageView.save.test.ts, ensuring you
keep the authoritative tests for debounce/version-guard/error/unmount scenarios
(tests referencing useDocumentManagerStore, createDocument, updateDocument,
clearDocument) and remove the redundant suite, update any test imports or CI
references, and run the test suite to confirm no regressions.

In
`@apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts`:
- Around line 79-281: Tests are mutating useDocumentManagerStore and calling
mockPatch directly so they never exercise CanvasPageView's debounce/unmount save
effects; refactor by keeping 1–2 pure store unit tests but move lifecycle
assertions into component-level tests that render CanvasPageView, simulate user
edits by invoking the MonacoEditor onChange handler (or dispatching events that
trigger CanvasPageView's update flow), advance fake timers to flush the
debounce, unmount the component to trigger the force-save effect, and then
assert store state and mockPatch calls to verify real save/version-guard/error
behaviors.

In `@apps/web/src/hooks/page-agents/__tests__/usePageAgents.test.ts`:
- Around line 53-55: The tests repeatedly extract the SWR mock call and its
config using the same pattern (vi.mocked(useSWR).mock.calls[...] and casting to
the config shape); create a small helper function (e.g., getMockSWRConfig or
extractSWRConfig) used by tests in usePageAgents.test.ts to accept the call
index (or default to 0) and return the typed config object so you can replace
repeated lines that compute swrCall and swrConfig (references: useSWR, swrCall,
swrConfig) with a single call to the helper to reduce duplication and improve
maintainability.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1e70a3 and 6b3e966.

📒 Files selected for processing (13)
  • apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx
  • apps/web/src/components/layout/middle-content/content-header/EditableTitle.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts
  • apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/canvas-save-lifecycle.test.ts
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx
  • apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
  • apps/web/src/hooks/__tests__/useDevices.test.ts
  • apps/web/src/hooks/page-agents/__tests__/usePageAgents.test.ts
  • apps/web/src/hooks/page-agents/usePageAgents.ts
  • apps/web/src/hooks/useDevices.ts
  • apps/web/src/stores/useDocumentStore.ts
  • apps/web/src/stores/useTabsStore.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/hooks/page-agents/usePageAgents.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/stores/useDocumentStore.ts
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx

…ding

- Guard post-unmount clearDocument with lastUpdateTime snapshot so a
  remount race can't clear the new component's document state
- Add ReactNode type import in CanvasPageView.save.test.ts (was using
  unimported React.ReactNode)
- Encode user.id with encodeURIComponent in permission-check URLs
  (SheetView + DocumentView) to handle reserved characters safely

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx (1)

131-152: ⚠️ Potential issue | 🟡 Minor

Fail closed while permission state is unresolved.

isReadOnly can temporarily retain the previous page’s value until the new request resolves, which can briefly enable edits on a page that may be read-only. Set isReadOnly(true) before the request and keep it true on non-OK/error paths.

🔧 Suggested adjustment
   useEffect(() => {
     const abortController = new AbortController();

     const checkPermissions = async () => {
       if (!user?.id) return;
+      setIsReadOnly(true);

       try {
         const response = await fetchWithAuth(
           `/api/pages/${pageId}/permissions/check?userId=${encodeURIComponent(user.id)}`,
           { signal: abortController.signal }
         );
         if (response.ok) {
           const permissions = await response.json();
           setIsReadOnly(!permissions.canEdit);
           if (!permissions.canEdit) {
             toast.info("You don't have permission to edit this document", {
               duration: 4000,
               position: 'bottom-right'
             });
           }
+        } else {
+          setIsReadOnly(true);
         }
       } catch (error) {
         if ((error as Error).name === 'AbortError') return;
+        setIsReadOnly(true);
         console.error('Failed to check permissions:', error);
       }
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx`
around lines 131 - 152, In checkPermissions, fail closed by setting
isReadOnly(true) immediately before making the fetch and ensure any non-OK or
error paths (except AbortError) leave isReadOnly true; then only call
setIsReadOnly(!permissions.canEdit) after a successful response.ok and JSON
parse. Update the fetchWithAuth handling in checkPermissions to set
isReadOnly(true) prior to the request, set isReadOnly(true) when response.ok is
false, and in the catch block (except when error.name === 'AbortError') also set
isReadOnly(true) before logging the error so the previous page’s writable state
cannot leak.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx`:
- Around line 25-27: The initial render falls back to an empty string causing a
flash of blank content; update the content fallback in the component to use the
page's server-provided content instead of '' — i.e., where you derive content
from useDocumentManagerStore (variable documentState and constant content),
change the fallback from '' to page.content so first paint shows initial
content; also sweep the other occurrences mentioned (the similar logic around
lines 98-111) and apply the same change to any other uses that default to ''
when documentState is not yet populated.
- Around line 66-76: saveContent calls from CanvasPageView currently omit
expectedRevision, allowing stale PATCHes to overwrite newer edits; either (A)
add revision tracking to the document store (e.g., store currentRevision on the
page state in useDocumentManagerStore and keep it updated when server responses
arrive) and change saveContent calls in CanvasPageView (and the unmount cleanup
path where saveTimeoutRef/ saveVersionRef are used) to include expectedRevision
when PATCHing, or (B) enforce single in-flight save by wiring an AbortController
per-page: store the controller on save start, pass its signal into saveContent,
abort any prior controller before starting a new save, and also abort pending
saves on unmount so stale requests cannot complete. Ensure references to
saveContent, saveTimeoutRef, saveVersionRef, and
useDocumentManagerStore.updateDocument are updated accordingly.

---

Outside diff comments:
In
`@apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx`:
- Around line 131-152: In checkPermissions, fail closed by setting
isReadOnly(true) immediately before making the fetch and ensure any non-OK or
error paths (except AbortError) leave isReadOnly true; then only call
setIsReadOnly(!permissions.canEdit) after a successful response.ok and JSON
parse. Update the fetchWithAuth handling in checkPermissions to set
isReadOnly(true) prior to the request, set isReadOnly(true) when response.ok is
false, and in the catch block (except when error.name === 'AbortError') also set
isReadOnly(true) before logging the error so the previous page’s writable state
cannot leak.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b3e966 and 10080f5.

📒 Files selected for processing (4)
  • apps/web/src/components/layout/middle-content/page-views/canvas/CanvasPageView.tsx
  • apps/web/src/components/layout/middle-content/page-views/canvas/__tests__/CanvasPageView.save.test.ts
  • apps/web/src/components/layout/middle-content/page-views/document/DocumentView.tsx
  • apps/web/src/components/layout/middle-content/page-views/sheet/SheetView.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/components/layout/middle-content/page-views/canvas/tests/CanvasPageView.save.test.ts

…tedRevision with saves

- Use page.content as content fallback instead of empty string to prevent
  blank flash before document store hydrates
- Send expectedRevision with PATCH requests for optimistic locking
- Track and update revision from server responses (saves and socket updates)
- Seed revision from page props on document initialization
- Include page.revision in React.memo comparator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios merged commit 9d3fb9d into master Feb 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant